package org.codersunit.radboid.data.parser;


import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.HashMap;
import java.util.Map;

public abstract class HtmlParser {
	/**
	 * Character indicating a tag is opened
	 */
	public static final char OPEN_TAG = '<';
	
	/**
	 * Character indicating a tag is closed
	 */
	public static final char CLOSE_TAG = '>';
	
	/**
	 * Character indicating an element is closed
	 */
	public static final char CLOSE_ELEMENT = '/';
	
	/**
	 * Character indicating the start of an entity
	 */
	public static final char OPEN_ENTITY = '&';
	
	/**
	 * Character indicating the end of and entity
	 */
	public static final char CLOSE_ENTITY = ';';
	
	/**
	 * Character indicating a numbered entity
	 */
	public static final char NUMBER_ENTITY = '#';
	
	/**
	 * Character indicating the start and end of a double quoted string
	 */
	public static final char ATTR_QUOTE = '"';
	
	/**
	 * Character indicating the start and end of a single quoted string
	 */
	public static final char ATTR_APOS = '\'';
	
	/**
	 * Character indicating the value of an attribute follows
	 */
	public static final char ATTR_VALUE_START = '=';
	
	/**
	 * Character indicating the next element is either a doctype or a comment
	 */
	public static final char SPECIAL_ELEMENT = '!';
	
	/**
	 * Character indicating the element is a comment
	 */
	public static final char COMMENT = '-';
	
	/**
	 * If set to true, the next time the parser would start parsing a new node, it will instead stop parsing.
	 */
	private boolean stopNext = false;
	
	/**
	 * Storage of characters during parsing
	 */
	private CodePointBuffer builder = null;
	
	/**
	 * Reader from which the characters are read
	 */
	private BufferedReader reader = null;
	
	/**
	 * Read a stream of characters and parses it (as simple as possible) as a html document, might not 
	 * catch all invalid html documents
	 * @param stream
	 * @return
	 */
	public boolean parse(Reader stream) {
		reader = new BufferedReader(stream);
		try {
			int current;
			builder = new CodePointBuffer(200);
			while(!stopNext && (current = reader.read()) >= 0) {
				if(current == OPEN_TAG) {
					if(builder.length() > 0) {
						handleText(builder.toString());
						if(stopNext) {
							break;
						}
						builder.clear();
					}
					if(!parseTag()) {
						return false;
					}
				} else {
					builder.appendCodePoint(current);
				}
			}
		} catch(IOException e) {
			return false;
		}
		return true;
	}
	
	/**
	 * Can be called to stop parsing as soon as a next node would normally be parsed. 
	 */
	protected void stop() 
	{
		stopNext = true;
	}
	
	/**
	 * Parses a tag element, either closing or opening. Depending on what type of element is found 
	 * HandleOpenTag, HandleCloseTag, HandleComment or HandleDoctype is called with the correct
	 * arguments for the current tag. Returns true if parsing was a success, false otherwise.
	 * @return
	 */
	private boolean parseTag() 
	{
		try {
			reader.mark(1);
			int current = reader.read();
			if(current < 0) {
				return false;
			} else if(current == CLOSE_ELEMENT) {
				return parseCloseTag();
			} else if(current == SPECIAL_ELEMENT) {
				return parseSpecialTag();
			} else {
				reader.reset();
				return parseOpenTag();
			}
		} catch(IOException e) {
			return false;
		}
	}
	
	/**
	 * Parses a special tag element. A special tag element is either a doctype or a comment. Calls
	 * either HandleComment if a comment was found or HandleDoctype if a doctype was found. Returns
	 * true if parsing was a success, false if some error was found.
	 * @return
	 */
	private boolean parseSpecialTag()
	{
		try {
			reader.mark(2);
			int current = reader.read();
			if(current == COMMENT) {
				current = reader.read();
				if(current == COMMENT) {
					return parseComment();
				} else {
					reader.reset();
				}
			}
			
			builder.clear();
			do {
				current = reader.read();
				if(current < 0) {
					return false;
				} else if(current != CLOSE_TAG) {
					builder.appendCodePoint(current);
				}
			} while(current != CLOSE_TAG);
			handleDoctype(builder.toString());
			return true;
		} catch(IOException e) {
			return false;
		}
	}
	
	/**
	 * Parses a comment element. The text inside of the comment is parsed until the end
	 * of the comment is found. Then the HandleComment function is called with the content
	 * of this comment element (does not include syntax elements). Returns true if parsing
	 * the element was a success, false if something failed.
	 * @return
	 */
	private boolean parseComment() 
	{
		int first = -1;
		int second = -1;
		int third = -1;
		builder.clear();
		try {
			int current;
			do {
				current = reader.read();
				if(current < 0) {
					return false;
				} else {
					if(first >= 0) {
						builder.appendCodePoint(first);
					}
					first = second;
					second = third;
					third = current;
				}
			} while(first != COMMENT && second != COMMENT && third != CLOSE_TAG);
			reader.read();
			handleComment(builder.toString());
			return true;
		} catch(IOException e) {
			return false;
		}
	}
	
	/**
	 * Parses a tag that is closing an element. After the tag is removed from
	 * the input stream the HandleCloseTag function is called with the name of
	 * the element that was closed.
	 * @return
	 */
	private boolean parseCloseTag()
	{
		try {
			int current;
			builder.clear();
			do {
				current = reader.read();
				if(current < 0) {
					return false;
				} else {
					if(current != CLOSE_TAG) {
						builder.appendCodePoint(current);
					}
				}
			} while(current != CLOSE_TAG);
			handleEndElement(builder.toString());
			return true;
		} catch(IOException e) {
			return false;
		}
	}
	
	/**
	 * Parses a tag that opens an element. After the tag (including attributes) are parsed
	 * the HandleOpenElement function is called with the tag name, attributes and whether it is
	 * self-closing or not. Returns true if parsing the tag was a success, false otherwise.
	 * @return
	 */
	private boolean parseOpenTag()
	{
		try {
			boolean isSelfClosing = false;
			int current;
			builder.clear();
			do {
				current = reader.read();
				if(current < 0) {
					return false;
				} else {
					if(!Character.isWhitespace(current) && current != CLOSE_ELEMENT && current != CLOSE_TAG) {
						builder.appendCodePoint(current);
					} else if(current == CLOSE_ELEMENT) {
						isSelfClosing = true;
					}
				}
			} while(!Character.isWhitespace(current) && current != CLOSE_ELEMENT && current != CLOSE_TAG);
			
			if(isSelfClosing || current == CLOSE_TAG) {
				handleStartElement(builder.toString(), new HashMap<String, String>(), isSelfClosing);
				return true;
			} else {
				return parseTagAttributes(builder.toString());
			}
		} catch(IOException e) {
			return false;
		}
	}
	
	/**
	 * Parses the attributes of a tag. The name of the tag is given to this function so that it may
	 * call the HandleOpenTag function with the correct tagname. Returns true if parsing was a success,
	 * otherwise it returns false.
	 * @param tagName
	 * @return
	 */
	private boolean parseTagAttributes(String tagName)
	{
		try {
			int current;
			builder.clear();
			HashMap<String, String> attrs = new HashMap<String, String>();
			boolean lastWasClose = false;
			
			do {
				current = reader.read();
				if(current < 0) {
					return false;
				} else {
					if(current == CLOSE_ELEMENT || current == CLOSE_TAG) {
						attrs.put(builder.toString(), null);
					} else if(current == ATTR_VALUE_START) {
						String name = builder.toString();
						current = reader.read();
						if(current == ATTR_QUOTE && !parseToNext(ATTR_QUOTE)) {
							return false;
						} else if(current == ATTR_APOS && !parseToNext(ATTR_APOS)) {
							return false;
						} else if(current != ATTR_QUOTE && current != ATTR_APOS && !parseToWhitespace()) {
							return false;
						}
						attrs.put(name, builder.toString());
						builder.clear();
					} else if(!Character.isWhitespace(current)) {
						builder.appendCodePoint(current);
					}
					
					if(current == CLOSE_ELEMENT) {
						lastWasClose = true;
					} else if(!Character.isWhitespace(current) && current != CLOSE_TAG) {
						lastWasClose = false;
					}
					
				}
			} while(current != CLOSE_TAG);
			handleStartElement(tagName, attrs, lastWasClose);
		} catch(IOException e) {
			return false;
		}
		return true;
	}
	
	/**
	 * Puts all characters until a given character is found in the builder. Removes the given character 
	 * from the input stream once it has been encountered. Returns true if this was a success, false 
	 * otherwise. 
	 * @param ch
	 * @return
	 */
	private boolean parseToNext(char ch)
	{
		builder.clear();
		try {
			int current;
			do {
				current = reader.read();
				if(current < 0) {
					return false;
				} else if(current != ch) {
					builder.appendCodePoint(current);
				}
			} while(current != ch);
			return true;
		} catch(IOException e) {
			return false;
		}
	}
	
	/**
	 * Puts all characters until a whitespace character is encountered in the builder. The first
	 * whitespace character is also removed from the input stream, but succeeding whitespace characters
	 * will not be removed. Returns true if the operation succeeded, false if there is an input error
	 * or if no whitespace could be found.
	 * @return
	 */
	private boolean parseToWhitespace()
	{
		builder.clear();
		try {
			int current;
			do {
				current = reader.read();
				if(current < 0) {
					return false;
				} else if(!Character.isWhitespace(current)) {
					builder.appendCodePoint(current);
				}
			} while(!Character.isWhitespace(current));
			return true;
		} catch(IOException e) {
			return false;
		}
	}
	
	/**
	 * When a tag indicating the start of an element is encountered, this function is called.
	 * If selfClosed is true, this element is closed by the same tag. 
	 * @param name
	 * @param attrs
	 * @param selfClosed
	 */
	protected abstract void handleStartElement(String name, Map<String, String> attrs, boolean selfClosed);
	
	protected abstract void handleEndElement(String name);
	
	protected abstract void handleText(String text);
	
	protected abstract void handleDoctype(String doctype);
	
	protected abstract void handleComment(String comment);
	
	public class CodePointBuffer {
		private char[] storage;
		private int current;
		
		public CodePointBuffer(int capacity) {
			storage = new char[capacity];
			current = 0;
		}
		
		public void expand(int minNewSize) {
			int newsize = storage.length;
			while(newsize <= minNewSize) {
				newsize = newsize * 2;
			}
			char[] newStorage = new char[newsize];
			System.arraycopy(storage, 0, newStorage, 0, current);
			storage = newStorage;
		}
		
		public void clear() {
			current = 0;
		}
		
		public void appendCodePoint(int code) {
			if(current >= storage.length) {
				expand(current);
			}
			storage[current] = (char) code;
			current += 1;
		}
		
		@Override
		public String toString() {
			return new String(storage, 0, current);
		}
		
		public int length() {
			return current;
		}
	}
}
